🎖️GitЯра🎖️
Commit 5ac26be18f1023bcc88dac66526c0ad80e2e6a9d
Parents : 1d0dc8b
Author : Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-16T15:55:42-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-16T17:55:42-05:00
feat(node): add local stats noise floor metrics (#5782)
Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changes
25 files changed, 667 insertions(+), 45 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 73cc216ed7..876bde34af 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -119,6 +119,7 @@ bluetooth_permission
bold_heading
bottom_nav_settings
broadcast_interval
+busy_noise_floor
button_gpio
buzzer_gpio
calculating
@@ -978,6 +979,9 @@ nodes_empty_disconnected_title
nodes_empty_searching_hint
nodes_empty_searching_title
nodes_queued_for_deletion
+noise_floor
+noise_floor_definition
+noise_floor_no_reading
none
none_quality
not_connected
@@ -1151,6 +1155,7 @@ request_air_quality_metrics
request_device_metrics
request_environment_metrics
request_host_metrics
+request_local_stats
request_metadata
request_pax_metrics
request_power_metrics
@@ -1202,6 +1207,7 @@ routing_error_rate_limit_exceeded
routing_error_timeout
routing_error_too_large
rssi
+rssi_definition
rsyslog_server
sample_message
sats
@@ -1296,6 +1302,7 @@ skip
slot
smart_position
snr
+snr_definition
soil_moisture
soil_temperature
speed
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
index 56799b7c7c..57e6312b0c 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
@@ -174,6 +174,25 @@ open class MeshLogRepositoryImpl(
dbManager.currentDb.value.meshLogDao().deleteLogs(logId, portNum)
}
+ /** Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry types. */
+ override suspend fun deleteLocalStatsLogs(nodeNum: Int) = withContext(dispatchers.io) {
+ val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum
+ val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum
+ val dao = dbManager.currentDb.value.meshLogDao()
+ val localStatsLogs =
+ dao.getLogsFrom(logId, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE)
+ .firstOrNull()
+ .orEmpty()
+ .map { it.asExternalModel() }
+ .filter { parseTelemetryLog(it)?.local_stats != null }
+
+ val localStatsLogIds = localStatsLogs.map { it.uuid }
+ // Chunk to stay under SQLite's bind-variable limit; re-fetch DAO per chunk if the active DB switches.
+ for (chunk in localStatsLogIds.chunked(DELETE_CHUNK_SIZE)) {
+ dbManager.currentDb.value.meshLogDao().deleteLogsByUuid(chunk)
+ }
+ }
+
/** Prunes the log database based on the configured [retentionDays]. */
@Suppress("MagicNumber")
override suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) {
@@ -183,5 +202,8 @@ open class MeshLogRepositoryImpl(
companion object {
private const val MILLIS_PER_SEC = 1000L
+
+ /** Max UUIDs per DELETE IN-clause; keeps us under SQLite's bind-variable limit. */
+ private const val DELETE_CHUNK_SIZE = 500
}
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt
index 9f57efa8a4..ebd21a17b8 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt
@@ -32,8 +32,10 @@ import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeDatabaseProvider
import org.meshtastic.core.testing.FakeMeshLogPrefs
import org.meshtastic.proto.Data
+import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
@@ -144,4 +146,73 @@ abstract class CommonMeshLogRepositoryTest {
val logs = repository.getAllLogsUnbounded().first()
assertTrue(logs.isEmpty())
}
+
+ @Test
+ fun `deleteLocalStatsLogs deletes only local stats telemetry`() = runTest(testDispatcher) {
+ val nodeNum = 1234
+ val localStatsLog =
+ telemetryLog(
+ uuid = "local-stats",
+ nodeNum = nodeNum,
+ telemetry = Telemetry(local_stats = LocalStats(noise_floor = -112)),
+ receivedDate = nowMillis + 3,
+ )
+ val deviceLog =
+ telemetryLog(
+ uuid = "device",
+ nodeNum = nodeNum,
+ telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 80)),
+ receivedDate = nowMillis + 2,
+ )
+ val environmentLog =
+ telemetryLog(
+ uuid = "environment",
+ nodeNum = nodeNum,
+ telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 21f)),
+ receivedDate = nowMillis + 1,
+ )
+ val localStatsRequestLog =
+ telemetryLog(
+ uuid = "local-stats-request",
+ nodeNum = nodeNum,
+ telemetry = Telemetry(local_stats = LocalStats()),
+ receivedDate = nowMillis,
+ wantResponse = true,
+ )
+
+ listOf(localStatsLog, deviceLog, environmentLog, localStatsRequestLog).forEach { repository.insert(it) }
+
+ repository.deleteLocalStatsLogs(nodeNum)
+
+ val remainingIds = repository.getAllLogsUnbounded().first().map { it.uuid }.toSet()
+ assertEquals(setOf("device", "environment", "local-stats-request"), remainingIds)
+ }
+
+ private fun telemetryLog(
+ uuid: String,
+ nodeNum: Int,
+ telemetry: Telemetry,
+ receivedDate: Long,
+ wantResponse: Boolean = false,
+ ) = MeshLog(
+ uuid = uuid,
+ message_type = "telemetry",
+ received_date = receivedDate,
+ raw_message = "",
+ fromNum = nodeNum,
+ portNum = PortNum.TELEMETRY_APP.value,
+ fromRadio =
+ FromRadio(
+ packet =
+ MeshPacket(
+ from = nodeNum,
+ decoded =
+ Data(
+ payload = telemetry.encode().toByteString(),
+ portnum = PortNum.TELEMETRY_APP,
+ want_response = wantResponse,
+ ),
+ ),
+ ),
+ )
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
index 1353d43f62..3edabfc150 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
@@ -53,6 +53,9 @@ interface MeshLogDao {
@Query("DELETE FROM log WHERE uuid = :uuid")
suspend fun deleteLog(uuid: String)
+ @Query("DELETE FROM log WHERE uuid IN (:uuids)")
+ suspend fun deleteLogsByUuid(uuids: List<String>)
+
@Query("DELETE FROM log WHERE from_num = :fromNum AND port_num = :portNum")
suspend fun deleteLogs(fromNum: Int, portNum: Int)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt
index 95a19496d1..e9f2ec80b7 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt
@@ -73,6 +73,9 @@ interface MeshLogRepository {
/** Deletes all logs associated with a specific [nodeNum] and [portNum]. */
suspend fun deleteLogs(nodeNum: Int, portNum: Int)
+ /** Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry logs. */
+ suspend fun deleteLocalStatsLogs(nodeNum: Int)
+
/** Prunes the log database based on the configured [retentionDays]. */
suspend fun deleteLogsOlderThan(retentionDays: Int)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 5bd0d50655..dc7f111423 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -137,6 +137,7 @@
<string name="bold_heading">Bold Heading</string>
<string name="bottom_nav_settings">Settings</string>
<string name="broadcast_interval">Broadcast Interval</string>
+ <string name="busy_noise_floor">Busy floor</string>
<string name="button_gpio">Button GPIO</string>
<string name="buzzer_gpio">Buzzer GPIO</string>
<string name="calculating">Calculating…</string>
@@ -1008,6 +1009,9 @@
<string name="nodes_empty_searching_hint">Nearby nodes will appear here as they're discovered.</string>
<string name="nodes_empty_searching_title">Searching for nodes</string>
<string name="nodes_queued_for_deletion">%1$d nodes queued for deletion:</string>
+ <string name="noise_floor">Noise Floor</string>
+ <string name="noise_floor_definition">The background RF noise measured by the radio receiver. Lower values usually indicate a quieter receiver environment; -85 dBm is a busy reference point, not a hard failure threshold.</string>
+ <string name="noise_floor_no_reading">Noise Floor: No reading</string>
<string name="none">None (disable)</string>
<string name="none_quality">None</string>
<string name="not_connected">Not connected</string>
@@ -1193,6 +1197,7 @@
<string name="request_device_metrics">Device Metrics</string>
<string name="request_environment_metrics">Environment Metrics</string>
<string name="request_host_metrics">Host Metrics</string>
+ <string name="request_local_stats">Local Stats</string>
<string name="request_metadata">Metadata</string>
<string name="request_pax_metrics">Pax Metrics</string>
<string name="request_power_metrics">Power Metrics</string>
@@ -1244,6 +1249,7 @@
<string name="routing_error_timeout">Timeout</string>
<string name="routing_error_too_large">Packet too large</string>
<string name="rssi">RSSI</string>
+ <string name="rssi_definition">Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.</string>
<string name="rsyslog_server">rsyslog server</string>
<string name="sample_message" translatable="false">hey I found the cache, it is over here next to the big tiger. I'm kinda scared.</string>
<string name="sats">Sats</string>
@@ -1338,6 +1344,7 @@
<string name="slot">Slot</string>
<string name="smart_position">Smart Position</string>
<string name="snr">SNR</string>
+ <string name="snr_definition">Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission.</string>
<string name="soil_moisture">Soil Moist</string>
<string name="soil_temperature">Soil Temp</string>
<string name="speed">Speed</string>
@@ -1576,4 +1583,3 @@
<string name="zh_CN" translatable="false">简体中文</string>
<string name="zh_TW" translatable="false">繁體中文</string>
</resources>
-
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt
index bde19b092d..a5e1567413 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt
@@ -41,10 +41,14 @@ class FakeMeshLogRepository :
var deleteAllCalled = false
private set
+ var lastDeletedLocalStatsNodeNum: Int? = null
+ private set
+
override fun reset() {
super.reset()
lastDeletedOlderThan = null
deleteAllCalled = false
+ lastDeletedLocalStatsNodeNum = null
}
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> = logsFlow.map { it.take(maxItem) }
@@ -82,6 +86,14 @@ class FakeMeshLogRepository :
logsFlow.value = logsFlow.value.filterNot { it.fromNum == nodeNum && it.portNum == portNum }
}
+ override suspend fun deleteLocalStatsLogs(nodeNum: Int) {
+ lastDeletedLocalStatsNodeNum = nodeNum
+ logsFlow.value =
+ logsFlow.value.filterNot { log ->
+ log.fromNum == nodeNum && log.portNum == PortNum.TELEMETRY_APP.value && log.hasLocalStatsTelemetry()
+ }
+ }
+
override suspend fun deleteLogsOlderThan(retentionDays: Int) {
lastDeletedOlderThan = retentionDays
}
@@ -89,4 +101,11 @@ class FakeMeshLogRepository :
fun setLogs(logs: List<MeshLog>) {
logsFlow.value = logs
}
+
+ private fun MeshLog.hasLocalStatsTelemetry(): Boolean = runCatching {
+ val decoded = fromRadio.packet?.decoded ?: return false
+ if (decoded.want_response == true) return false
+ Telemetry.ADAPTER.decode(decoded.payload).local_stats != null
+ }
+ .getOrDefault(false)
}
diff --git a/docs/en/user/node-metrics.md b/docs/en/user/node-metrics.md
index a3f2884100..8c29ac610d 100644
--- a/docs/en/user/node-metrics.md
+++ b/docs/en/user/node-metrics.md
@@ -2,7 +2,7 @@
title: Node Metrics
parent: User Guide
nav_order: 5
-last_updated: 2026-06-11
+last_updated: 2026-06-16
description: Telemetry dashboards for each mesh node — device health, environment sensors, air quality, signal quality, power, traceroute, and position history.
aliases:
- metrics
@@ -87,6 +87,7 @@ Radio signal quality information:
|--------|-------------|
| SNR | Signal-to-Noise Ratio (higher is better) |
| RSSI | Received Signal Strength Indicator (closer to 0 is better) |
+| Noise Floor | Local background RF noise in dBm (more negative is quieter) |
| Hop Count | Number of mesh hops for last message |
### Signal Quality Reference
@@ -98,6 +99,8 @@ Radio signal quality information:
| -10 to 0 dB | Fair |
| < -10 dB | Poor |
+Local Stats from your connected radio are also shown in Signal Quality when available. These logs include noise floor, traffic counters, relay counters, online node counts, and radio uptime. The noise floor chart uses a dashed reference line at -85 dBm to help identify a busy RF environment. Use **Request** to ask the connected radio for a fresh Local Stats telemetry report, **Clear** to remove Local Stats logs for that node, and **Save** to export the visible Local Stats history as CSV.
+
## Power Metrics
Power management telemetry (requires INA sensor or compatible hardware):
@@ -159,4 +162,3 @@ The position tab shows location data for nodes that share GPS:
- [Units & Locale](units-and-locale) — temperature, distance, and speed display formats
---
-
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
index b090a25995..adc92871d8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
@@ -136,6 +136,28 @@ fun TelemetricActionsSectionEmptyPreview() {
}
}
+@PreviewLightDark
+@Suppress("PreviewPublic")
+@Composable
+fun TelemetricActionsSectionLocalPreview() {
+ val node = previewData.mickeyMouse
+ AppTheme {
+ Surface {
+ TelemetricActionsSection(
+ node = node,
+ ourNode = node,
+ availableLogs = emptySet(),
+ lastTracerouteTime = null,
+ lastRequestNeighborsTime = null,
+ displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
+ isFahrenheit = false,
+ onAction = {},
+ isLocal = true,
+ )
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// PositionInlineContent preview
// ---------------------------------------------------------------------------
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
index 8faf43bae3..1698d6fb82 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
@@ -159,7 +159,6 @@ private fun rememberTelemetricFeatures(
icon = LogsType.SIGNAL.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
logsType = LogsType.SIGNAL,
- isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
index 0c1b695d77..aff51ae622 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
@@ -34,10 +34,10 @@ import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
+import org.meshtastic.core.resources.request_local_stats
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
-import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
import org.meshtastic.core.ui.util.SnackbarManager
@@ -90,7 +90,7 @@ constructor(
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
- TelemetryType.LOCAL_STATS -> Res.string.signal_quality
+ TelemetryType.LOCAL_STATS -> Res.string.request_local_stats
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
index 930479f81c..cd27746b75 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
@@ -198,6 +198,7 @@ constructor(
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
+ localStats = logs.telemetry.filter { it.local_stats != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
airQualityMetrics = logs.telemetry.filter { it.air_quality_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
@@ -222,7 +223,7 @@ constructor(
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
- if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
+ if (metricsState.hasSignalMetrics() || metricsState.hasLocalStats()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasAirQualityMetrics()) add(LogsType.AIR_QUALITY)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
index 7fc333da22..99a5e7869d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
@@ -238,6 +238,7 @@ fun AdaptiveMetricLayout(
* cooldown traceroute button).
* @param onExportCsv When non-null, a Save [IconButton] is rendered in the app bar that invokes this callback. This
* centralises the CSV export affordance so individual screens only need to provide the export logic.
+ * @param bottomContent Optional content pinned below the adaptive chart/list area.
*/
@Composable
@Suppress("LongMethod")
@@ -255,6 +256,8 @@ fun <T> BaseMetricScreen(
chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit,
listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit,
controlPart: @Composable () -> Unit = {},
+ bottomContent: @Composable () -> Unit = {},
+ modifier: Modifier = Modifier,
) {
var displayInfoDialog by rememberSaveable { mutableStateOf(false) }
var isChartExpanded by rememberSaveable { mutableStateOf(false) }
@@ -269,6 +272,7 @@ fun <T> BaseMetricScreen(
var selectedX by remember { mutableStateOf<Double?>(null) }
Scaffold(
+ modifier = modifier,
topBar = {
MainAppBar(
title = nodeName,
@@ -331,6 +335,7 @@ fun <T> BaseMetricScreen(
AdaptiveMetricLayout(
isChartExpanded = isChartExpanded,
+ modifier = Modifier.weight(1f),
chartPart = { modifier ->
chartPart(modifier, selectedX, vicoScrollState) { x ->
selectedX = x
@@ -349,6 +354,8 @@ fun <T> BaseMetricScreen(
}
},
)
+
+ bottomContent()
}
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 12e1b82679..032663661f 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -242,6 +242,10 @@ open class MetricsViewModel(
}
}
+ fun clearLocalStats() = safeLaunch(context = dispatchers.io, tag = "clearLocalStats") {
+ (manualNodeId.value ?: nodeIdFromRoute)?.let { meshLogRepository.deleteLocalStatsLogs(it) }
+ }
+
fun requestPosition() {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
viewModelScope.launch { nodeRequestActions.requestPosition(it, state.value.node?.user?.long_name ?: "") }
@@ -370,6 +374,26 @@ open class MetricsViewModel(
}
}
+ fun saveLocalStatsCSV(uri: CommonUri, data: List<Telemetry>) {
+ exportCsv(
+ uri = uri,
+ header =
+ "\"date\",\"time\",\"noise_floor_dbm\",\"uptime_seconds\",\"channel_utilization\",\"air_util_tx\"," +
+ "\"packets_tx\",\"packets_rx\",\"bad_rx\",\"rx_dupe\",\"tx_relay\",\"tx_relay_canceled\"," +
+ "\"online_nodes\",\"total_nodes\"\n",
+ rows = data.filter { it.local_stats != null },
+ epochSeconds = { it.time.toLong() },
+ ) { telemetry ->
+ val stats = telemetry.local_stats
+ "\"${stats?.noise_floor ?: ""}\",\"${stats?.uptime_seconds ?: ""}\"," +
+ "\"${stats?.channel_utilization ?: ""}\",\"${stats?.air_util_tx ?: ""}\"," +
+ "\"${stats?.num_packets_tx ?: ""}\",\"${stats?.num_packets_rx ?: ""}\"," +
+ "\"${stats?.num_packets_rx_bad ?: ""}\",\"${stats?.num_rx_dupe ?: ""}\"," +
+ "\"${stats?.num_tx_relay ?: ""}\",\"${stats?.num_tx_relay_canceled ?: ""}\"," +
+ "\"${stats?.num_online_nodes ?: ""}\",\"${stats?.num_total_nodes ?: ""}\""
+ }
+ }
+
fun saveDeviceMetricsCSV(uri: CommonUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
index fb43eba97f..7e0bf083b9 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
@@ -19,6 +19,8 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -29,7 +31,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -44,52 +49,132 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
+import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineModel
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
+import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
+import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.busy_noise_floor
+import org.meshtastic.core.resources.clear
+import org.meshtastic.core.resources.local_stats_bad
+import org.meshtastic.core.resources.local_stats_nodes
+import org.meshtastic.core.resources.local_stats_noise
+import org.meshtastic.core.resources.local_stats_relays
+import org.meshtastic.core.resources.local_stats_traffic
+import org.meshtastic.core.resources.local_stats_uptime
+import org.meshtastic.core.resources.no_local_stats
+import org.meshtastic.core.resources.noise_floor
+import org.meshtastic.core.resources.noise_floor_definition
+import org.meshtastic.core.resources.noise_floor_no_reading
+import org.meshtastic.core.resources.request
import org.meshtastic.core.resources.rssi
+import org.meshtastic.core.resources.rssi_definition
+import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.snr
+import org.meshtastic.core.resources.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
+import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.theme.GraphColors.Blue
+import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
+import org.meshtastic.core.ui.theme.GraphColors.Orange
+import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.Telemetry
+
+private const val QUIET_NOISE_FLOOR_DBM = -95
+private const val BUSY_FLOOR_DBM = -85
+private const val MIN_DBM_AXIS = -120.0
+private const val MAX_DBM_AXIS = 0.0
private enum class SignalMetric(val color: Color) {
+ NOISE_FLOOR(Gold),
+ BUSY_FLOOR(Red),
SNR(Green),
RSSI(Blue),
}
private val LEGEND_DATA =
listOf(
+ LegendData(nameRes = Res.string.noise_floor, color = SignalMetric.NOISE_FLOOR.color, isLine = true),
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color),
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color),
)
-@Suppress("LongMethod")
+private sealed interface SignalLogEntry {
+ val timeSeconds: Int
+
+ data class LocalStatsEntry(val telemetry: Telemetry) : SignalLogEntry {
+ override val timeSeconds: Int = telemetry.time
+ }
+
+ data class PacketEntry(val meshPacket: MeshPacket) : SignalLogEntry {
+ override val timeSeconds: Int = meshPacket.rx_time
+ }
+}
+
+@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
-fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
+fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
- val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() }
-
- val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, data) }
+ val threshold = timeFrame.timeThreshold()
+ val signalData = state.signalMetrics.filter { it.rx_time.toLong() >= threshold }
+ val localStatsData = state.localStats.filter { it.time.toLong() >= threshold && it.local_stats != null }
+ val data =
+ remember(signalData, localStatsData) {
+ (
+ localStatsData.map { SignalLogEntry.LocalStatsEntry(it) } +
+ signalData.map { SignalLogEntry.PacketEntry(it) }
+ )
+ .sortedByDescending { it.timeSeconds }
+ }
+ val hasNoiseFloor = remember(localStatsData) { localStatsData.any { it.local_stats?.noise_floor != 0 } }
+ val hasRssi = remember(signalData) { signalData.any { it.rx_rssi != 0 } }
+ val hasSnr = remember(signalData) { signalData.any { !it.rx_snr.isNaN() } }
+ val hasAnyLocalStats = state.localStats.isNotEmpty()
+ val localStatsExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveLocalStatsCSV(uri, localStatsData) }
+ val signalExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, signalData) }
BaseMetricScreen(
onNavigateUp = onNavigateUp,
- telemetryType = TelemetryType.LOCAL_STATS,
+ telemetryType = null,
titleRes = Res.string.signal_quality,
nodeName = state.node?.user?.long_name ?: "",
data = data,
- timeProvider = { it.rx_time.toDouble() },
- onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
- onExportCsv = { exportLauncher("signal_metrics.csv", "text/csv") },
+ timeProvider = { it.timeSeconds.toDouble() },
+ modifier = modifier,
+ onExportCsv =
+ if (signalData.isNotEmpty()) {
+ { signalExportLauncher("signal_metrics.csv", "text/csv") }
+ } else {
+ null
+ },
+ infoData =
+ buildList {
+ if (hasNoiseFloor) {
+ add(
+ InfoDialogData(
+ Res.string.noise_floor,
+ Res.string.noise_floor_definition,
+ SignalMetric.NOISE_FLOOR.color,
+ ),
+ )
+ }
+ if (hasSnr) add(InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color))
+ if (hasRssi) add(InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color))
+ },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,
@@ -98,55 +183,161 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
modifier = Modifier.padding(horizontal = 16.dp),
)
},
- chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
+ chartPart = { contentModifier, selectedX, vicoScrollState, onPointSelected ->
SignalMetricsChart(
- modifier = modifier,
- meshPackets = data.reversed(),
+ modifier = contentModifier,
+ localStats = localStatsData.reversed(),
+ meshPackets = signalData.reversed(),
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = onPointSelected,
)
},
- listPart = { modifier, selectedX, lazyListState, onCardClick ->
- LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
- itemsIndexed(data) { _, meshPacket ->
- SignalMetricsCard(
- meshPacket = meshPacket,
- isSelected = meshPacket.rx_time.toDouble() == selectedX,
- onClick = { onCardClick(meshPacket.rx_time.toDouble()) },
+ listPart = { contentModifier, selectedX, lazyListState, onCardClick ->
+ if (data.isEmpty()) {
+ Box(modifier = contentModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ text = stringResource(Res.string.no_local_stats),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ } else {
+ LazyColumn(modifier = contentModifier.fillMaxSize(), state = lazyListState) {
+ itemsIndexed(data) { _, entry ->
+ when (entry) {
+ is SignalLogEntry.LocalStatsEntry ->
+ LocalStatsCard(
+ telemetry = entry.telemetry,
+ isSelected = entry.timeSeconds.toDouble() == selectedX,
+ onClick = { onCardClick(entry.timeSeconds.toDouble()) },
+ )
+
+ is SignalLogEntry.PacketEntry ->
+ SignalMetricsCard(
+ meshPacket = entry.meshPacket,
+ isSelected = entry.timeSeconds.toDouble() == selectedX,
+ onClick = { onCardClick(entry.timeSeconds.toDouble()) },
+ )
+ }
+ }
+ }
}
},
+ bottomContent = {
+ LocalStatsActionButtons(
+ hasLocalStats = hasAnyLocalStats,
+ hasVisibleLocalStats = localStatsData.isNotEmpty(),
+ onClear = viewModel::clearLocalStats,
+ onRequest = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
+ onSave = { localStatsExportLauncher("local_stats.csv", "text/csv") },
+ )
+ },
)
}
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun LocalStatsActionButtons(
+ hasLocalStats: Boolean,
+ hasVisibleLocalStats: Boolean,
+ onClear: () -> Unit,
+ onRequest: () -> Unit,
+ onSave: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowRow(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (hasLocalStats) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = onClear,
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear))
+ Spacer(Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.clear), maxLines = 1)
+ }
+ }
+ OutlinedButton(modifier = Modifier.weight(1f), onClick = onRequest) {
+ Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = stringResource(Res.string.request))
+ Spacer(Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.request), maxLines = 1)
+ }
+ if (hasVisibleLocalStats) {
+ OutlinedButton(modifier = Modifier.fillMaxWidth(), onClick = onSave) {
+ Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save))
+ Spacer(Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.save), maxLines = 1)
+ }
+ }
+ }
+}
+
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
+ localStats: List<Telemetry>,
meshPackets: List<MeshPacket>,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- MetricChartScaffold(isEmpty = meshPackets.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
- modelProducer,
- chartModifier,
- ->
+ val noiseFloorData = remember(localStats) { localStats.filter { it.local_stats?.noise_floor != 0 } }
+ val busyFloorData =
+ remember(noiseFloorData) {
+ if (noiseFloorData.size > 1) listOf(noiseFloorData.first(), noiseFloorData.last()) else emptyList()
+ }
+ val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != 0 } }
+ val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
+ val legendData =
+ remember(noiseFloorData, rssiData, snrData) {
+ LEGEND_DATA.filter { legend ->
+ when (legend.nameRes) {
+ Res.string.noise_floor -> noiseFloorData.isNotEmpty()
+ Res.string.rssi -> rssiData.isNotEmpty()
+ Res.string.snr -> snrData.isNotEmpty()
+ else -> true
+ }
+ }
+ }
+
+ MetricChartScaffold(
+ isEmpty = meshPackets.isEmpty() && localStats.isEmpty(),
+ legendData = legendData,
+ modifier = modifier,
+ ) { modelProducer, chartModifier ->
+ val noiseFloorColor = SignalMetric.NOISE_FLOOR.color
+ val busyFloorColor = SignalMetric.BUSY_FLOOR.color
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
+ val noiseFloorLabel = stringResource(Res.string.noise_floor)
+ val busyFloorLabel = stringResource(Res.string.busy_noise_floor)
+ val rssiLabel = stringResource(Res.string.rssi)
+ val snrLabel = stringResource(Res.string.snr)
- val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != 0 } }
- val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
-
- LaunchedEffect(rssiData, snrData) {
+ LaunchedEffect(noiseFloorData, busyFloorData, rssiData, snrData) {
modelProducer.runTransaction {
+ if (noiseFloorData.isNotEmpty()) {
+ lineModel {
+ series(
+ x = noiseFloorData.map { it.time },
+ y = noiseFloorData.map { it.local_stats?.noise_floor ?: 0 },
+ )
+ }
+ }
+ if (busyFloorData.isNotEmpty()) {
+ lineModel { series(x = busyFloorData.map { it.time }, y = busyFloorData.map { BUSY_FLOOR_DBM }) }
+ }
if (rssiData.isNotEmpty()) {
- /* Use separate lineModel calls to associate them with different vertical axes */
lineModel { series(x = rssiData.map { it.rx_time }, y = rssiData.map { it.rx_rssi }) }
}
if (snrData.isNotEmpty()) {
+ /* Use a separate lineModel call to associate SNR with the right axis. */
lineModel { series(x = snrData.map { it.rx_time }, y = snrData.map { it.rx_snr }) }
}
}
@@ -156,21 +347,38 @@ private fun SignalMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
- if (color == rssiColor) {
- "RSSI: ${MetricFormatter.rssi(value.toInt())}"
- } else {
- "SNR: ${MetricFormatter.snr(value.toFloat())}"
+ when (color.copy(alpha = 1f)) {
+ noiseFloorColor -> "$noiseFloorLabel: ${MetricFormatter.rssi(value.toInt())}"
+ busyFloorColor -> "$busyFloorLabel: ${MetricFormatter.rssi(value.toInt())}"
+ rssiColor -> "$rssiLabel: ${MetricFormatter.rssi(value.toInt())}"
+ snrColor -> "$snrLabel: ${MetricFormatter.snr(value.toFloat())}"
+ else -> value.toString()
}
},
)
+ val dbmRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = MIN_DBM_AXIS, maxY = MAX_DBM_AXIS) }
+ val noiseFloorLayer =
+ rememberConditionalLayer(
+ hasData = noiseFloorData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(noiseFloorColor)),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = dbmRangeProvider,
+ )
+ val busyFloorLayer =
+ rememberConditionalLayer(
+ hasData = busyFloorData.isNotEmpty(),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(busyFloorColor)),
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = dbmRangeProvider,
+ )
val rssiLayer =
rememberConditionalLayer(
hasData = rssiData.isNotEmpty(),
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
+ lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(rssiColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
+ rangeProvider = dbmRangeProvider,
)
-
val snrLayer =
rememberConditionalLayer(
hasData = snrData.isNotEmpty(),
@@ -178,7 +386,10 @@ private fun SignalMetricsChart(
verticalAxisPosition = Axis.Position.Vertical.End,
)
- val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) }
+ val layers =
+ remember(noiseFloorLayer, busyFloorLayer, rssiLayer, snrLayer) {
+ listOfNotNull(noiseFloorLayer, busyFloorLayer, rssiLayer, snrLayer)
+ }
if (layers.isNotEmpty()) {
GenericMetricChart(
@@ -186,9 +397,12 @@ private fun SignalMetricsChart(
modifier = chartModifier,
layers = layers,
startAxis =
- if (rssiData.isNotEmpty()) {
+ if (noiseFloorData.isNotEmpty() || rssiData.isNotEmpty()) {
VerticalAxis.rememberStart(
- label = ChartStyling.rememberAxisLabel(color = rssiColor),
+ label =
+ ChartStyling.rememberAxisLabel(
+ color = if (noiseFloorData.isNotEmpty()) noiseFloorColor else rssiColor,
+ ),
valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) },
)
} else {
@@ -213,6 +427,97 @@ private fun SignalMetricsChart(
}
}
+@Composable
+private fun noiseFloorTextColor(value: Int): Color = when {
+ value == 0 -> MaterialTheme.colorScheme.onSurfaceVariant
+ value < QUIET_NOISE_FLOOR_DBM -> SignalMetric.SNR.color
+ value < BUSY_FLOOR_DBM -> Orange
+ else -> MaterialTheme.colorScheme.error
+}
+
+@Suppress("LongMethod")
+@Composable
+private fun LocalStatsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
+ val localStats = telemetry.local_stats
+ val time = telemetry.time.toLong() * MS_PER_SEC
+ val noiseFloor = localStats?.noise_floor ?: 0
+
+ SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
+ Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text = DateFormatter.formatDateTime(time),
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Text(
+ text =
+ if (noiseFloor != 0) {
+ stringResource(Res.string.local_stats_noise, noiseFloor)
+ } else {
+ stringResource(Res.string.noise_floor_no_reading)
+ },
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = noiseFloorTextColor(noiseFloor),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text =
+ stringResource(
+ Res.string.local_stats_traffic,
+ localStats?.num_packets_tx ?: 0,
+ localStats?.num_packets_rx ?: 0,
+ localStats?.num_rx_dupe ?: 0,
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Text(
+ text =
+ stringResource(
+ Res.string.local_stats_relays,
+ localStats?.num_tx_relay ?: 0,
+ localStats?.num_tx_relay_canceled ?: 0,
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text =
+ stringResource(
+ Res.string.local_stats_nodes,
+ localStats?.num_online_nodes ?: 0,
+ localStats?.num_total_nodes ?: 0,
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Text(
+ text = stringResource(Res.string.local_stats_uptime, formatUptime(localStats?.uptime_seconds ?: 0)),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+
+ if ((localStats?.num_packets_rx_bad ?: 0) > 0) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(Res.string.local_stats_bad, localStats?.num_packets_rx_bad ?: 0),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
index 7e89b66042..c53b6c8270 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
@@ -34,6 +34,7 @@ data class MetricsState(
val displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
val node: Node? = null,
val deviceMetrics: List<Telemetry> = emptyList(),
+ val localStats: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),
val powerMetrics: List<Telemetry> = emptyList(),
val hostMetrics: List<Telemetry> = emptyList(),
@@ -57,6 +58,8 @@ data class MetricsState(
fun hasSignalMetrics() = signalMetrics.isNotEmpty()
+ fun hasLocalStats() = localStats.isNotEmpty()
+
fun hasPowerMetrics() = powerMetrics.isNotEmpty()
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
@@ -74,7 +77,8 @@ data class MetricsState(
/** Finds the oldest timestamp (in seconds) among all collected metric types. */
@Suppress("MagicNumber")
fun oldestTimestampSeconds(): Long? {
- val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics + airQualityMetrics).map { it.time.toLong() }
+ val telemetryTimes =
+ (deviceMetrics + localStats + powerMetrics + hostMetrics + airQualityMetrics).map { it.time.toLong() }
val signalTimes = signalMetrics.map { it.rx_time.toLong() }
val logTimes =
(tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map {
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
index c520ac9d46..df7238bff4 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
@@ -49,6 +49,7 @@ import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
+import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
@@ -378,6 +379,78 @@ class MetricsViewModelTest {
}
}
+ @Test
+ fun `saveLocalStatsCSV writes only provided visible data`() = runTest(testDispatcher) {
+ val visibleTelemetry =
+ Telemetry(
+ time = 1700000000,
+ local_stats =
+ LocalStats(
+ noise_floor = -112,
+ uptime_seconds = 3600,
+ channel_utilization = 12.5f,
+ air_util_tx = 3.25f,
+ num_packets_tx = 2,
+ num_packets_rx = 3,
+ num_packets_rx_bad = 1,
+ num_rx_dupe = 4,
+ num_tx_relay = 5,
+ num_tx_relay_canceled = 6,
+ num_online_nodes = 7,
+ num_total_nodes = 8,
+ ),
+ )
+ val hiddenTelemetry =
+ Telemetry(time = 1600000000, local_stats = LocalStats(noise_floor = -99, uptime_seconds = 10))
+
+ val nodeDetailFlow =
+ MutableStateFlow(
+ NodeDetailUiState(
+ metricsState = MetricsState(localStats = listOf(visibleTelemetry, hiddenTelemetry)),
+ ),
+ )
+ every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
+
+ val buffer = Buffer()
+ everySuspend { fileService.write(any(), any()) } calls
+ { args ->
+ val block = args.arg<suspend (BufferedSink) -> Unit>(1)
+ block(buffer)
+ true
+ }
+
+ val vm = createViewModel()
+ vm.state.test {
+ awaitItem()
+ awaitItem()
+
+ val uri = CommonUri.parse("content://test")
+ vm.saveLocalStatsCSV(uri, listOf(visibleTelemetry))
+ runCurrent()
+
+ verifySuspend { fileService.write(uri, any()) }
+
+ val csvOutput = buffer.readUtf8()
+ assertTrue(csvOutput.startsWith("\"date\",\"time\",\"noise_floor_dbm\",\"uptime_seconds\""))
+ assertTrue(csvOutput.contains("\"-112\",\"3600\",\"12.5\",\"3.25\""))
+ assertTrue(csvOutput.contains("\"2\",\"3\",\"1\",\"4\",\"5\",\"6\",\"7\",\"8\""))
+ assertTrue(!csvOutput.contains("-99"), "Should only export the rows supplied by the screen")
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `clearLocalStats deletes local stats logs for route node`() = runTest(testDispatcher) {
+ everySuspend { meshLogRepository.deleteLocalStatsLogs(1234) } returns Unit
+
+ val vm = createViewModel()
+ vm.clearLocalStats()
+ runCurrent()
+
+ verifySuspend { meshLogRepository.deleteLocalStatsLogs(1234) }
+ }
+
@Test
fun `savePowerMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/MetricsStateTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/MetricsStateTest.kt
new file mode 100644
index 0000000000..69b0fa2698
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/MetricsStateTest.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.model
+
+import org.meshtastic.proto.LocalStats
+import org.meshtastic.proto.Telemetry
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class MetricsStateTest {
+
+ @Test
+ fun hasLocalStatsReturnsTrueWhenLocalStatsTelemetryExists() {
+ val state = MetricsState(localStats = listOf(localStatsTelemetry(time = 123)))
+
+ assertTrue(state.hasLocalStats())
+ }
+
+ @Test
+ fun oldestTimestampIncludesLocalStatsTelemetry() {
+ val state =
+ MetricsState(
+ deviceMetrics = listOf(Telemetry(time = 200)),
+ localStats = listOf(localStatsTelemetry(time = 100)),
+ )
+
+ assertEquals(100L, state.oldestTimestampSeconds())
+ }
+
+ private fun localStatsTelemetry(time: Int) = Telemetry(time = time, local_stats = LocalStats(noise_floor = -101))
+}
diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
index e425022eac..cc784d4206 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
@@ -31,6 +31,7 @@ import org.meshtastic.feature.node.component.NodeItemCompleteOnlineRemotePreview
import org.meshtastic.feature.node.component.NodeItemCompletePreview
import org.meshtastic.feature.node.component.PositionInlineContentPreview
import org.meshtastic.feature.node.component.TelemetricActionsSectionEmptyPreview
+import org.meshtastic.feature.node.component.TelemetricActionsSectionLocalPreview
import org.meshtastic.feature.node.component.TelemetricActionsSectionPreview
import org.meshtastic.feature.node.detail.NodeDetailContentLoadingPreview
import org.meshtastic.feature.node.detail.NodeDetailContentLocalPreview
@@ -69,6 +70,13 @@ fun ScreenshotTelemetricActionsSectionEmpty() {
TelemetricActionsSectionEmptyPreview()
}
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotTelemetricActionsSectionLocal() {
+ TelemetricActionsSectionLocalPreview()
+}
+
@PreviewTest
@PreviewLightDark
@Composable
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Dark_d19fbf1f_0.png
index ad154eb5d4..8aedf9f1bc 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Light_b29dc7a7_0.png
index d5f6a001db..d93d73be83 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotDeviceActionsLocal_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Dark_d19fbf1f_0.png
index c939e54530..110c886b73 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Light_b29dc7a7_0.png
index c556cd9f98..4077ee2bd9 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentLocal_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotTelemetricActionsSectionLocal_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotTelemetricActionsSectionLocal_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..9524061fca
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotTelemetricActionsSectionLocal_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotTelemetricActionsSectionLocal_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotTelemetricActionsSectionLocal_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..7dd979c5a1
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotTelemetricActionsSectionLocal_Light_b29dc7a7_0.png differ
Served by rngit 1.5.0 - Generated in 0.34s